Newer
Older
client_dwatch / app / api / clean-logs / [id] / advice / route.ts
/**
 * GET /clean-logs/{id}/advice — 対応アドバイス(行展開時の推定原因+確認アクション)
 *
 * 仕様の正は `d-watch-api.yaml` の /clean-logs/{id}/advice です。
 *
 * 判定(どのルールが該当するか)は `lib/judge/advice.ts`、
 * 文言は `lib/settings/advice.ts`(運用設定で編集された内容)から取得します。
 */
import { NextResponse } from "next/server";
import { parsePathId } from "@/lib/api/path-params";
import {
  CONSECUTIVE_DAYS_DISPLAY,
  ESCALATION_THRESHOLDS,
  GRADE_THRESHOLDS,
} from "@/lib/config";
import { db } from "@/lib/db";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { judgeEscalationLevel, matchAdviceRules, maxAdviceLevel } from "@/lib/judge";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { loadAdviceRules } from "@/lib/settings/advice";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type { ApiError, CleanLogAdviceResponse, ErrorCode } from "@/lib/types";

const ROUTE = "/api/clean-logs/[id]/advice";

function errorBody(code: ErrorCode, message: string): ApiError {
  return { error: { code, message, details: null } };
}

function errorResponse(status: number, code: ErrorCode, message: string) {
  return NextResponse.json(errorBody(code, message), { status });
}

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  return withRequestId(request, async () => {
    const { id } = await params;
    // ★`Number.parseInt` の前方一致に頼らない★(`lib/api/path-params.ts` 参照。
    //   `"12abc"` / `"1.9"` / `"-5"` が黙って通っていた。指示書AE 段階2-2 / D-123)
    const parsedId = parsePathId(id);
    if (!parsedId.ok) {
      return NextResponse.json(parsedId.error, { status: parsedId.status });
    }
    const logId = parsedId.query;

    let logs;
    try {
      // ★★1件だけ引く★★(指示書AC 段階3)— 以前は「範囲なし」で全件(実測3,178行)を
      //   読んでから `find()` していた。
      //   ★★連続日数は `loadConsecutiveInputs()` 経由なので正しいまま★★(段階1 の効果)—
      //   `cleanLogs()` の中で ★全期間を読む専用の1本★ が別に走るので、
      //   ★`id` で絞っても L3 が L1 に化けない★(指示書AC C-2)。
      //   ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」の意味なので
      //   ★全部 404 になります★(罠1)。
      logs = await db.cleanLogs({
        id: logId,
        date_from: null,
        date_to: null,
        facility_ids: null,
      });
    } catch (e) {
      const body = errorBody("internal_error", "データの取得に失敗しました");
      logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
      return NextResponse.json(body, { status: 500 });
    }

    // ★`db` が1件だけ返すようになっても `find()` は**残す**★(指示書AC C-5)—
    //   ★絞り込み規則の「正」はここ★で、SQL 側はその写し。
    //   ★★fixtures 版が `id` の扱いを間違えても 404 が壊れない保険★★にもなる。
    const log = logs.find((l) => l.id === logId);
    if (!log) {
      return errorResponse(404, "not_found", "指定された清掃ログが見つかりません");
    }

    // ★★入力は生値★★ — R1〜R4 の判定に表示上限をかけない(指示書AF 禁止事項9)。
    //   ★このルートで `capConsecutiveDays()` を通してよいのはレスポンスに載せる値だけ★
    //   (下の `body`)。判定の入力と、判定根拠を残す `logEvent` は生値のままにする。
    const matchedIds = matchAdviceRules({
      departed: log.departed,
      completionRate: log.completion_rate,
      consecutiveProblemDays: log.consecutive_problem_days,
      // ★null(事前チェックの予定が無い。D-171)はそのまま渡す★ — `matchAdviceRules()` は null を
      //   どのルールにも当てない(例外にしない)。★ここで "Standby." 等に置き換えないこと★
      status1h: log.status_1h,
      status30m: log.status_30m,
    });

    // 判定根拠の記録。記録条件は設けず無条件に出す
    // (このルートは行クリックで1件だけ呼ばれるため件数の問題が無く、
    //  条件分岐を入れると「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい)
    logEvent("info", {
      event: "judge.advice",
      route: ROUTE,
      method: "GET",
      facility_id: log.facility_id,
      // ★`log.id` は型の上で null を含む(予定外の行。指示書BG §2-1)が、`find()` で `logId` と一致した行なので同じ値★
      clean_log_id: logId,
      departed: log.departed,
      raw_completion_rate: log.completion_rate,
      consecutive_problem_days: log.consecutive_problem_days,
      status_1h: log.status_1h,
      status_30m: log.status_30m,
      thresholds_used: {
        excellent: GRADE_THRESHOLDS.excellent,
        good: GRADE_THRESHOLDS.good,
        poor: GRADE_THRESHOLDS.poor,
        level2Days: ESCALATION_THRESHOLDS.level2Days,
        level3Days: ESCALATION_THRESHOLDS.level3Days,
      },
      result_rules: matchedIds,
    });

    // ★読み込みの例外は標準の形の500にする★(指示書BB A5・D18。`try` の外で投げると標準でない500・`api.error` なし)
    let rules: Awaited<ReturnType<typeof loadAdviceRules>>;
    try {
      rules = await loadAdviceRules();
    } catch (e) {
      const body = errorBody("internal_error", "データの取得に失敗しました");
      logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
      return NextResponse.json(body, { status: 500 });
    }
    const advices = matchedIds.map((ruleId) => {
      const r = rules[ruleId];
      return {
        rule: r.id,
        level: r.level,
        icon: r.icon,
        title: r.title,
        estimated_cause: r.text,
        recommended_actions: r.actions,
      };
    });

    // ★★入力は生値★★ — L1〜L3 の判定に表示上限をかけない(指示書AF 禁止事項9 /
    //   `CLAUDE.md` §4.3「表示用に丸めた値で判定すること」の禁止と同じ思想)。
    //   上限をかけると「30日以上」と出ているのに Lv3 が出ない画面になる。
    const escalationLevel = judgeEscalationLevel(log.consecutive_problem_days);
    logEvent("info", {
      event: "judge.escalation",
      route: ROUTE,
      method: "GET",
      facility_id: log.facility_id,
      consecutive_problem_days: log.consecutive_problem_days,
      thresholds_used: {
        level2Days: ESCALATION_THRESHOLDS.level2Days,
        level3Days: ESCALATION_THRESHOLDS.level3Days,
      },
      result_level: escalationLevel,
    });

    // ★★ここだけが表示上限をかけてよい場所★★(指示書AF 段階6-2)—
    //   判定(`matchAdviceRules` / `judgeEscalationLevel`)も判定根拠の `logEvent` も
    //   生値のままで、レスポンスに載せる表示値だけを頭打ちにする。
    //   `escalation_level` は生値で判定した結果をそのまま返す(判定し直さない)。
    //   設定が読めないだけでアドバイスを 500 にしない(既定値で続行する)。
    let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax;
    try {
      maxDays = await loadConsecutiveDaysMax();
    } catch {
      // 既定値のまま続行する
    }
    const capped = capConsecutiveDays(log.consecutive_problem_days, maxDays);

    const body: CleanLogAdviceResponse = {
      escalation_level: escalationLevel,
      consecutive_problem_days: capped.days,
      consecutive_problem_days_capped: capped.capped,
      max_level: maxAdviceLevel(advices),
      advices,
    };
    return NextResponse.json(body);
  });
}